diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/(reports)/breakdown | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)/breakdown')
4 files changed, 200 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx new file mode 100644 index 0000000..4532d97 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx @@ -0,0 +1,91 @@ +import { Column, DataColumn, DataTable, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks'; +import { formatShortTime } from '@/lib/format'; + +export interface BreakdownProps { + websiteId: string; + startDate: Date; + endDate: Date; + selectedFields: string[]; +} + +export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { fields } = useFields(); + const { data, error, isLoading } = useResultQuery<any>( + 'breakdown', + { + websiteId, + startDate, + endDate, + fields: selectedFields, + }, + { enabled: !!selectedFields.length }, + ); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Column overflow="auto" minHeight="0" height="100%"> + <DataTable data={data} style={{ tableLayout: 'fixed' }}> + {selectedFields.map(field => { + return ( + <DataColumn + key={field} + id={field} + label={fields.find(f => f.name === field)?.label} + width="minmax(120px, 1fr)" + > + {row => { + const value = formatValue(row[field], field); + return ( + <Text truncate title={value}> + {value} + </Text> + ); + }} + </DataColumn> + ); + })} + <DataColumn + id="visitors" + label={formatMessage(labels.visitors)} + align="end" + width="120px" + > + {row => row?.visitors?.toLocaleString()} + </DataColumn> + <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px"> + {row => row?.visits?.toLocaleString()} + </DataColumn> + <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px"> + {row => row?.views?.toLocaleString()} + </DataColumn> + <DataColumn + id="bounceRate" + label={formatMessage(labels.bounceRate)} + align="end" + width="120px" + > + {row => { + const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100; + return `${Math.round(+n)}%`; + }} + </DataColumn> + <DataColumn + id="visitDuration" + label={formatMessage(labels.visitDuration)} + align="end" + width="120px" + > + {row => { + const n = row?.totaltime / row?.visits; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + </DataColumn> + </DataTable> + </Column> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx new file mode 100644 index 0000000..fdead9f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx @@ -0,0 +1,51 @@ +'use client'; +import { Column, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { ListCheck } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { Breakdown } from './Breakdown'; + +export function BreakdownPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [fields, setFields] = useState(['path']); + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Row alignItems="center" justifyContent="flex-start"> + <FieldsButton value={fields} onChange={setFields} /> + </Row> + <Panel height="900px" overflow="auto" allowFullscreen> + <Breakdown + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + selectedFields={fields} + /> + </Panel> + </Column> + ); +} + +const FieldsButton = ({ value, onChange }) => { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<ListCheck />} + label={formatMessage(labels.fields)} + width="400px" + minHeight="300px" + variant="outline" + > + {({ close }) => { + return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />; + }} + </DialogButton> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx new file mode 100644 index 0000000..28e3368 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx @@ -0,0 +1,46 @@ +import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; +import { useState } from 'react'; +import { useFields, useMessages } from '@/components/hooks'; + +export function FieldSelectForm({ + selectedFields = [], + onChange, + onClose, +}: { + selectedFields?: string[]; + onChange: (values: string[]) => void; + onClose?: () => void; +}) { + const [selected, setSelected] = useState(selectedFields); + const { formatMessage, labels } = useMessages(); + const { fields } = useFields(); + + const handleChange = (value: string[]) => { + setSelected(value); + }; + + const handleApply = () => { + onChange?.(selected); + onClose(); + }; + + return ( + <Column gap="6"> + <List value={selected} onChange={handleChange} selectionMode="multiple"> + {fields.map(({ name, label }) => { + return ( + <ListItem key={name} id={name}> + {label} + </ListItem> + ); + })} + </List> + <Grid columns="1fr 1fr" gap> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <Button onPress={handleApply} variant="primary"> + {formatMessage(labels.apply)} + </Button> + </Grid> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx new file mode 100644 index 0000000..841d863 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { BreakdownPage } from './BreakdownPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <BreakdownPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Insights', +}; |